Skip to content

release-25-06-26#214

Merged
pugazhendhi-m merged 18 commits into
mainfrom
staging
Jun 25, 2026
Merged

release-25-06-26#214
pugazhendhi-m merged 18 commits into
mainfrom
staging

Conversation

@pugazhendhi-m

@pugazhendhi-m pugazhendhi-m commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Note

Medium Risk
Changes core detection and multi-user scan paths (Junie, Cowork, Copilot, admin MCP), which can shift reported inventory; mcp-remote and audit-user behavior affect production scan side effects and backend attribution.

Overview
This release hardens coding discovery so reported installs match real software, scan lifecycle events attribute machines to humans (or omit owner), and MCP probing cannot trigger interactive OAuth.

Detection false positives are addressed across several tools. Junie no longer treats ~/.junie as proof of install; it requires the CLI binary (new find_junie_binary_for_user) or a Junie JetBrains plugin, with per-user IDE scans so another user’s plugin is not attributed. Claude Cowork on Linux/Windows now requires both the session tree and a present Claude Desktop install (config residue after uninstall no longer counts); Windows install probing uses the scanned user’s profile. GitHub Copilot (VS Code) on Windows reads the live extensions.json registry instead of globbing leftover extension folders. Admin/root MCP extraction skips re-processing Path.home() when that profile is already in the C:\Users scan (WEB-4673), avoiding duplicate configs on Windows.

Audit attribution adds get_audit_user() / _real_user_or_none() to map service, root, NT SERVICE, and machine accounts to None, while get_user_info() stays a non-None string for path building. Scan events (in_progress / completed / failed) optionally include system_user when a real human is resolved.

MCP scan safety: configs that launch mcp-remote without a cached token are not spawned; scans return auth_required instead of opening a browser OAuth flow. scan_single_mcp_server forwards hook-provided script_content and always attempts reporting (no longer skips zero-tool results).

Operator UX: default CLI output is concise (--summary removed); --dump restores per-merge and report-summary detail via a detail_logger. Scheduled onboard no longer requires --discovery-key (WEB-4891); discovery key remains optional for MDM all-users flows.

Claude Cowork is registered for Linux in the tool factory. Extensive unit tests cover residue detection, cross-user Junie attribution, admin home dedup, and audit user filtering.

Reviewed by Cursor Bugbot for commit 955b8b8. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This release adds Linux support for the GitHub Copilot CLI (detector + all five extractors), fixes false-positive tool detection across Junie, Cursor CLI, Copilot CLI, and Claude Cowork by gating on binaries/plugins instead of residual config dirs, introduces a human-identity filter (get_audit_user) so service/machine accounts are never attributed as scan owners, and resolves a Windows admin double-scan (WEB-4673) where the admin's own profile was counted twice.

  • mcp-remote auth gate (mcp_extraction_helpers.py): a new pre-scan check prevents spawning mcp-remote without a cached OAuth token (which would open a browser tab); the check reads the token cache path from Path.home() rather than the per-user home, causing false auth_required for all non-root users under a root/MDM scan.
  • Binary-gated detection (Junie macOS/Linux/Windows, Copilot CLI macOS/Linux, Cursor CLI): detectors now require a real binary or plugin signal instead of an on-disk config dir that survives uninstall; install_path is now the binary, with a new internal _config_path field carrying the config dir used by extractors.
  • Audit identity (utils.py): get_audit_user() returns a real human or None; system_user is now included in all scan lifecycle events (in_progress, completed, failed); --summary CLI flag is removed (concise output is now the default).

Confidence Score: 3/5

Safe to merge for non-root single-user deployments; root/MDM multi-user scans will silently under-report mcp-remote servers for non-root users until the token-path bug is fixed.

The _mcp_remote_has_cached_token function resolves the token cache against the running process's home (Path.home()) rather than the home of the user whose MCP config is being scanned. Under a root/MDM enterprise scan, every non-root user's mcp-remote servers will be marked auth_required regardless of whether that user actually has a cached token, causing those servers to be silently skipped. The rest of the changes — binary-gated Junie/Cursor/Copilot detection, the audit-identity filter, the Windows double-scan fix — are well-structured and well-tested.

scripts/coding_discovery_tools/mcp_extraction_helpers.py (the new _mcp_remote_has_cached_token function and its call site in _scan_servers_in_mapping) needs a fix to thread user_home through so the token cache is read from the correct user's home directory.

Important Files Changed

Filename Overview
scripts/coding_discovery_tools/mcp_extraction_helpers.py Adds mcp-remote auth-gate (skips OAuth spawn when no cached token) and fixes Windows admin double-scan (WEB-4673). The auth-gate has a bug: _mcp_remote_has_cached_token uses Path.home() instead of the scanned user's home, causing false auth_required under root/MDM scans.
scripts/coding_discovery_tools/user_tool_detector.py Adds find_junie_binary_for_user, find_cursor_agent_binary_for_user, and _detect_junie; fixes Cursor CLI and Claude Cowork false-positive detection. Junie binary search iterates machine_global before user_relative (inverted vs cursor-agent).
scripts/coding_discovery_tools/utils.py Adds get_audit_user() and _real_user_or_none() for human-identity filtering; fixes a pre-existing Windows get_user_info() bug where username was checked before it was assigned. Adds system_user to scan-event payloads.
scripts/coding_discovery_tools/linux/copilot_cli/copilot_cli.py New Linux Copilot CLI detector that inherits from MacOSCopilotCliDetector and overrides only the binary resolver and all-users scan. Clean DRY design.
scripts/coding_discovery_tools/vscode_extension_helpers.py New shared helper for reading the VS Code-family extensions registry (extensions.json), providing an authoritative 'is this extension live?' signal that survives uninstall artifacts.
scripts/coding_discovery_tools/ai_tools_discovery.py Switches system_user from get_user_info() to get_audit_user() (human-or-None); makes concise output the default (removing --summary, adding detail_logger); keys Copilot CLI ownership gate on _config_path instead of install_path (now the binary). Increases default timeout to 150 min.
scripts/coding_discovery_tools/macos/copilot_cli/copilot_cli.py Switches Copilot CLI detection gate from ~/.copilot dir to the copilot binary; stores config dir as _config_path (internal) and binary as install_path. Removes the stale STRONG/SHARED marker logic.
scripts/coding_discovery_tools/scan_single_mcp_server.py Removes early-return on zero tools so auth_required and other non-scan results are forwarded to the backend; forwards script_content from hook-attached local-script servers.
scripts/coding_discovery_tools/linux/junie/junie.py Gates Junie detection on binary or JetBrains plugin presence instead of ~/.junie directory, eliminating false positives from uninstall residue.
setup-scheduled-scan.sh Makes --discovery-key optional for onboard command (WEB-4891 per-user API key flow); only sets UNBOUND_DISCOVERY_KEY env var when the key is non-empty.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Scan Start - main] --> B[get_audit_user]
    B -->|real human or None| C[send in_progress event with system_user]
    C --> D[For each tool / user_home]
    D --> E{tool_name?}
    E -->|cursor cli| F[find_cursor_agent_binary_for_user]
    E -->|junie| G[find_junie_binary_for_user]
    E -->|copilot cli| H[_resolve_binary]
    E -->|claude cowork| I[sessions_dir + _find_install_dir]
    F --> J[install_path = binary]
    G --> J
    H --> K[install_path = binary, _config_path = ~/.copilot]
    I --> J
    K --> L[extractors keyed on _config_path]
    J --> M[extractors keyed on install_path]
    L --> N[_scan_servers_in_mapping]
    M --> N
    N --> O{mcp-remote config?}
    O -->|yes| P[_mcp_remote_has_cached_token Path.home/.mcp-auth uses scanner home]
    P -->|token found| Q[Scan normally]
    P -->|no token| R[Return auth_required skip spawn]
    O -->|no| Q
    Q --> S[generate_single_tool_report]
    R --> S
    S --> T[send_report_to_backend with system_user]
    T --> U[send completed event with system_user]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Scan Start - main] --> B[get_audit_user]
    B -->|real human or None| C[send in_progress event with system_user]
    C --> D[For each tool / user_home]
    D --> E{tool_name?}
    E -->|cursor cli| F[find_cursor_agent_binary_for_user]
    E -->|junie| G[find_junie_binary_for_user]
    E -->|copilot cli| H[_resolve_binary]
    E -->|claude cowork| I[sessions_dir + _find_install_dir]
    F --> J[install_path = binary]
    G --> J
    H --> K[install_path = binary, _config_path = ~/.copilot]
    I --> J
    K --> L[extractors keyed on _config_path]
    J --> M[extractors keyed on install_path]
    L --> N[_scan_servers_in_mapping]
    M --> N
    N --> O{mcp-remote config?}
    O -->|yes| P[_mcp_remote_has_cached_token Path.home/.mcp-auth uses scanner home]
    P -->|token found| Q[Scan normally]
    P -->|no token| R[Return auth_required skip spawn]
    O -->|no| Q
    Q --> S[generate_single_tool_report]
    R --> S
    S --> T[send_report_to_backend with system_user]
    T --> U[send completed event with system_user]
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into staging" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

anonpran and others added 17 commits June 11, 2026 19:49
… CLI, extensions.json-gate Cline/Roo/Kilo (#198)

* fix(discovery): gate Cursor/Copilot CLI on binary + Cline/Roo/Kilo on extensions.json (Tier B)

Tier-B detector-accuracy follow-up to #193 — fixes the 5 remaining detectors that
gated 'installed' on config/globalStorage residue that survives uninstall (or is
created by another tool). All 5 doc-verified vs official vendor docs.

Extensions (Cline, Roo, Kilo Code): gate on the ext-id being a live entry in
<editor>/extensions/extensions.json (the install registry VS Code rewrites on
uninstall) via a new shared vscode_extension_helpers.find_extension_in_editor
(case-insensitive id — fixes Kilo's lowercase-on-disk kilocode.kilo-code, silently
broken on Linux ext4). Dropped the globalStorage check (VS Code won't clean it —
microsoft/vscode#119022) and the host-IDE co-check. Roo gains a VSCodium host.

CLIs (Cursor CLI, Copilot CLI): gate on the binary. Cursor CLI -> cursor-agent (and
fixed the .detect() fallback probing 'cursor' = the IDE launcher, which mis-labeled
the IDE as the CLI). Copilot CLI -> the copilot binary (npm + Homebrew, owner-
attributed under root); + NEW Linux detector (Copilot CLI was undetected on Linux).
install_path is now the binary, so a config_path field carries ~/.copilot and the
orchestrator's permissions/skills/ownership key on it (else permissions silently drop).

Detection-only, no backend/FE change. Tests: integration-level both-directions per
tool/OS incl. globalStorage-residue-without-entry FP-kill, the Cursor IDE-mislabel
guard, Copilot hooks-only/machine-global cases, and config_path full-chain attribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(copilot-cli): skip the X_OK non-exec test on Windows

test_non_executable_binary_not_detected asserts a non-executable ~/.local/bin/copilot
is not detected, but os.access(X_OK) returns True for ANY file on Windows -> the binary
reads executable there and the test fails (Windows CI). The X_OK gate is POSIX-only;
gate the test with @unittest.skipIf(os.name=='nt'), matching the existing Claude
test_non_executable_binary_not_detected skip. macOS (3.9/3.11/3.12) already green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot-cli): get_version uses self._resolve_binary, not the module resolver (Greptile)

get_version() resolved the binary via the module-level _resolve_copilot_binary (per-user
only: ~/.local/bin, ~/.bun/bin, nvm), but detection uses self._resolve_binary, which
Linux/Windows override to add the npm-global prefix, /usr/local/bin, and AppData npm. So a
copilot detected via those OS-specific locations reported version 'unknown' (get_version's
fallback shells 'copilot --version' on the scanner PATH, empty under root MDM). Route
get_version through self._resolve_binary so version resolution matches detection on every OS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(discovery): close 3 binary-coverage gaps — Cursor Win-native, Copilot WinGet + Linuxbrew

Coverage verification vs official install docs/scripts found 3 documented install methods
the binary resolvers didn't probe -> false negatives:

1. Cursor CLI Windows: the native installer (irm cursor.com/install?win32) writes
   %LOCALAPPDATA%\cursor-agent\{cursor-agent,agent}.{exe,cmd} + versions\<v>\, but we probed
   only %USERPROFILE%\.local\bin\cursor-agent.exe -> every native-Win user missed. Added the
   LOCALAPPDATA paths (existence-gated) + versioned subdir (numeric-newest) + the Git-Bash
   extensionless ~/.local/bin/cursor-agent. Hoisted _version_key to module level.

2. Copilot CLI Windows: 'winget install GitHub.Copilot' drops a Links shim; verified vs the
   winget manifest (Commands: [copilot]) the shim is copilot.exe -> added
   %LOCALAPPDATA%\Microsoft\WinGet\Links\copilot.exe (mirrors the Claude WinGet path).

3. Copilot CLI Linux: 'brew install copilot-cli' is official on Linux (Linuxbrew) -> added
   ~/.linuxbrew/bin/copilot (user-relative) + /home/linuxbrew/.linuxbrew/bin/copilot
   (machine-global, owner-attributed under root via machine_global_binary_owned_by_user -- no
   cross-user FP). /usr/local/bin now also owner-gated under root.

Tests: both directions, hermetic; the 6 positive tests fail against pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(copilot-cli): rename internal config_path field -> _config_path (review WARNING)

The Copilot CLI detector emits the resolved ~/.copilot dir so the orchestrator can
attribute per-user settings/skills/ownership (install_path is the binary now). The field
was named 'config_path' (no underscore), so generate_single_tool_report did NOT strip it
-> it leaked to the backend payload (exposing the user's home path). Rename to '_config_path'
to match the internal-field convention (JetBrains uses _config_path) so it's stripped.
Consumed BEFORE the strip (ownership/skills/permissions attribution unchanged), absent from
the sent payload.

Sites: the emit (macOS copilot_cli, inherited by win/linux) + the 5 orchestrator reads/carry/
log + docstrings. Tests updated; new test_config_path_stripped_from_backend_report asserts the
field drives attribution but never reaches the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve Cursor/Copilot CLI version from the detected binary, not the scanner PATH

Both detectors found the binary correctly (install_path) but fetched the VERSION indirectly
— Cursor via a bare 'cursor-agent --version' against the scanner's PATH, Copilot by re-resolving
via self.user_home — so under a root/MDM all-users scan the version read 'Unknown' even though
the binary was found (root's PATH lacks the user's copy). Thread the already-resolved binary into
get_version(self, binary=None) (backward-compatible: no-arg keeps the old behaviour);
_detect_cursor_cli passes cursor_agent_bin, Copilot _detect_for_user passes the binary it
resolved. Probe '<binary> --version' directly — no re-resolve, no bare-PATH fallback.

Preserves doc-verified caveats: the --version FLAG (offline, not the network 'version'
subcommand); the multi-line-banner parsers; VERSION_TIMEOUT + swallow-to-unknown; the Windows
.cmd shim shell=True path (Cursor Windows quotes the spaced path via list2cmdline; Copilot Windows
routes through _probe_version).

Tests: root-scan version resolution for both (binary off the scanner PATH / self.user_home unset
-> version still parsed from the resolved binary), proven non-vacuous; Windows quoted-path +
back-compat; existing version tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: trim verbose/task-specific comments to brief reason-only (PR #198)

Comments should convey the WHY, briefly — not the whole story. Removed task/PR/commit/review
references (93b5fc2, W1, doc-verified, device serials, follow-up asides), multi-sentence
narratives, and restatements of obvious code from THIS PR's added comments; kept the brief
non-obvious reasons (globalStorage survives uninstall, shell=True for the npm .cmd shim,
which/npm-prefix root-PATH guards, case-insensitive ext-id, Windows X_OK). Comments/docstrings
only — no code/logic changed (AST-verified); suite green (1200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(linux/copilot-cli): don't double-count other users' user-level skills on root scans

The Linux Copilot CLI skills extractor called is_user_level_claude_subdir(type_dir)
single-arg, which derives the users-root from Path.home(). On an MDM root scan
Path.home()==/root (parent /), so a NON-scanner user's /home/<user>/.agents/skills
(and .claude/skills) was not recognized as user-level and got re-emitted by the
project walk as a project skill -- even though _extract_user_level_skills already
emitted it user-scope. Result: duplicate / mis-scoped skill rows on the primary
(MDM root) Linux deployment.

Add a Linux-aware _is_user_level_skill_dir override (pin users-root to /home for
the /home/<user> shape + explicit /root check), mirroring macOS behavior that
worked only because all homes share /Users. Ported from PR #157's guard and its
TestLinuxRootSkillsUserLevelGuard regression test (this project has no Linux CI
runner, so the test is pure path-logic and runs on every runner).

Also resolves the open Greptile P2s on this PR:
- remove dead _check_ide_installation + now-unused imports (linux/cline,
  linux/roo_code) and the dead method + orphan IDE_APP_NAMES/APPLICATIONS_DIR
  (macos/kilocode), left over from dropping the host-editor AND-gate
- fix stale class docstrings still describing globalStorage gating
  (macos/cline, macos/roo_code) and install_path=~/.copilot (windows/copilot_cli)

1269 tests pass (+3 new). Detection-only, no backend/FE change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: audit <audit@local>
…(WEB-4673) (#167)

* fix(windows/copilot-cli): resolve version + dedup MCP on admin scans (WEB-4673)

Two Windows-only discovery bugs on the admin all-users path:

1. Version always "unknown": get_version() only ran bare `copilot` on PATH, but
   in an admin scan the scanner's PATH lacks each scanned user's npm bin. Now
   probe the detected user's own shim (<user_home>\AppData\Roaming\npm\copilot.cmd
   /.exe) first, scoped to self.user_home, then fall back to PATH. Also set
   self.user_home per-iteration in the admin all-users scan so the probe is
   scoped there too.

2. MCP servers double-counted: extract_ide_global_configs_with_root_support and
   extract_dual_path_configs_with_root_support iterate every C:\Users profile
   (incl. the admin's own) AND then re-add Path.home(). Correct on macOS/Linux
   (root home is outside the users root) but double-counts on Windows. Added
   _own_home_already_scanned() guard to skip the re-add when home is already
   covered by the scan.

Tests: 6 new (per-user version probe + PATH fallback + no-double-count +
still-adds-root-home-when-outside). Full suite 828 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* address review: guard remaining two MCP helpers + log resolved version (WEB-4673)

Review on #167:
- extract_claudeai_mcp_servers_with_root_support and
  extract_claude_plugin_mcp_configs_with_root_support had the same Windows
  double-count (re-scan the admin's own home after it was already covered by
  the C:\Users loop). Apply the same _own_home_already_scanned() guard so
  Claude.ai and plugin MCP servers aren't duplicated on admin scans.
- Greptile P2: the per-user shim probe now logs the resolved version + which
  binary it came from on success, matching the failure-path logging.

Tests: +2 (claudeai/plugin no-double-count). Full suite 830 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(discovery): detect home-rooted project-scope .mcp.json files (#166)

* fix(discovery): detect home-rooted project-scope .mcp.json files

The Claude Code project-scope MCP walk skipped a `.mcp.json` located
directly in a user's home directory (project root == home, e.g.
C:\Users\<u>\.mcp.json, /Users/<u>/.mcp.json, /home/<u>/.mcp.json).
`is_home_dotdir_descendant` — intended to skip the *contents of* hidden
home tool dirs (~/.cursor/, ~/.codex/) — also matched a home-rooted leaf
`.mcp.json`, so its servers were never reported. On a real device a
user-scope server in ~/.claude.json was detected while project-scope
servers in ~/.mcp.json (e.g. policycenter, playwright) were missed.

Fix: the walk's file branch now tests the file's parent directory
(`is_home_dotdir_descendant(entry.parent)`). A home-rooted `.mcp.json` is
read, while a `.mcp.json` inside a hidden home tool dir (~/.cursor/.mcp.json)
stays skipped. The directory-recursion branch and the sibling
directory-based walks (generic + skills) are intentionally unchanged.

Purely additive: for any path with >=5 segments old and new are identical;
only the 4-segment home-rooted leaf flips skip->read. No path flips
read->skip, so no previously-detected config can disappear.

Add tests/test_mcp_home_rooted_config.py: predicate regression tests
(POSIX + Windows-drive shapes), a walk-level test asserting the file branch
consults entry.parent (not the file), and a smoke test for normal projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(discovery): union MCP servers on path collision + cover Copilot CLI

Addresses principal-engineer review of the home-rooted .mcp.json fix.

H1: un-skipping a home-rooted ~/.mcp.json makes it emit a project entry
whose path == the home dir, which can collide with the ~/.claude.json
projects[<home>] (local-scope) entry. The merge functions overwrote
mcpServers by path (last-writer-wins), silently dropping one source's
servers. Add AIToolsDetector._union_mcp_servers (dedupe by name,
first/higher-precedence wins) and apply it at all three merge sites:
_merge_claude_mcp_configs_into_projects, _merge_mcp_configs_into_projects,
and the Copilot CLI inline merge. Also closes the pre-existing sub-folder
collision and stops additionalMcpData from clobbering an existing entry.

H2: Copilot CLI's Workspace .mcp.json uses the same project-scope walk, so
the home-rooted fix applies to it too; its inline merge now unions as well.

Tests: union helper, two-source home collision through the real Claude
merge, default-merge union, single-source-unchanged, Copilot shared-walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: detect built-in VS Code Copilot (all OS) + macOS Codex rules crash — direct to main (#169)

* fix(macos/github-copilot): detect built-in VS Code Copilot so its MCPs surface

VS Code now ships GitHub Copilot / Copilot Chat as BUILT-IN extensions in the
app bundle, which never appear in the per-user ~/.vscode/extensions/extensions.json
the detector reads. So users on built-in Copilot were never detected, and their
VS Code MCP servers (Code/User/mcp.json) were silently skipped.

_detect_vscode_for_user now falls back to scanning the VS Code app bundle's
built-in `copilot`/`copilot-chat` extension when no marketplace extension is
present, reading the version from package.json. Gated on the user actually
having a Code/User data dir so a machine-wide install isn't attributed to
unrelated users in a root scan. Detected "GitHub Copilot (VS Code)" then routes
through the existing MCP extractor, surfacing Code/User/mcp.json servers.

Verified on a real macOS box: built-in Copilot 0.51.0 detected -> 3 user MCP
servers (github-mcp-server, context7, playwright-mcp) extracted.

Also adds a regression test for the Codex should_process_directory('/' branch)
arg-count crash (the code fix already landed on staging; this guards it).

Full suite 835 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: log built-in Copilot detection outcomes + document single-result return

Addresses PR #169 review (4/5):
- _detect_vscode_builtin_copilot now logs all three outcomes (no VS Code data
  dir, built-in found w/ version+path, VS Code-but-no-built-in) at debug, so an
  admin scan resolving from a non-obvious app bundle is reconstructable.
- Documents the intentional at-most-one-result contract: one detection suffices
  to trigger downstream rules/MCP extraction; built-in Copilot bundles chat in
  the same `copilot` extension, so a second `copilot-chat` row would only
  double-process and duplicate MCP servers (unlike the marketplace path where
  the two are separate installs).

No behavior change. Detection tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(win/linux github-copilot): detect built-in VS Code Copilot (parity with macOS)

Extends the macOS built-in Copilot detection (this PR) to Windows and Linux, so
users on built-in VS Code Copilot — and their VS Code MCP servers
(%APPDATA%\Code\User\mcp.json / ~/.config/Code/User/mcp.json) — are no longer
missed on those OSes either.

- linux/github_copilot: _detect_vscode_for_user falls back to the VS Code
  install tree (deb/rpm, /opt, snap; stable + Insiders) when no marketplace
  extension is present, gated on a ~/.config/Code/User data dir.
- windows/github_copilot: same fallback over per-user (LocalAppData\Programs)
  and system (Program Files) installs, gated on %APPDATA%\Code\User. Also fixes
  an early-return that skipped detection entirely when .vscode\extensions was
  absent (the exact built-in-only case).
- Both log detection outcomes at debug and return at most one entry (a 2nd
  copilot-chat row would only duplicate the same MCP servers).

Adds Linux + Windows detection tests. Full suite 839 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden: guard non-dict package.json in built-in Copilot version read (audit P2)

A bundled copilot/package.json that parses as valid JSON but isn't an object
(array/string/number) made data.get("version") raise AttributeError, which
escapes the per-user loop and aborts detect_copilot() mid-iteration — silently
dropping ALL Copilot results (marketplace + built-in, every user) for the run.

Add an isinstance(data, dict) guard in _read_extension_version (macOS/Linux) and
the Windows built-in version read. Regression test with a non-dict package.json.

Full suite 840 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(macos/codex): pass root_path to should_process_directory (silent 0-rules crash)

main lacks the staging-only fix (#163): should_process_directory(dir_path) was
called with one arg but the helper requires (directory, root_path), raising a
TypeError on every '/' scan that was swallowed into 0 Codex project rules.
Included here so this main-targeted release carries both the fix and its
regression test (test_codex_rules_extraction.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(codex): exercise the real should_process_directory (not a stub)

Audit note: the regression test stubbed should_process_directory and only
asserted call arity, so it never exercised the real TypeError. Call THROUGH to
the real helper instead — a future signature/arity regression now raises the
actual production TypeError in the test (verified: fails with "missing 1
required positional argument: 'root_path'" when reverted to the one-arg call),
while still pinning the (directory, root_path) contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(github-copilot vscode): prove built-in fallback is purely additive

Add per-OS regression tests asserting that when a MARKETPLACE Copilot extension
is present, _detect_vscode_for_user returns it and the new built-in fallback is
never invoked (spy.assert_not_called) — locking in that existing detection
behavior is unchanged and the built-in path only runs when nothing was found.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(codex): make '/'-branch test cross-platform (fix Windows CI)

The codex regression test drove the macOS extractor's root_path==Path('/')
branch and asserted AGENTS.md was found. On Windows CI a C:\ temp path can't be
made relative to '/' (the walk's relative_to raises ValueError, skipping items),
so the file-finding assertion failed there — a test-only POSIX assumption, not a
product bug (the macOS extractor's '/' scan is POSIX-only).

- Keep the cross-platform contract check (should_process_directory called with
  (directory, root_path)); guard the AGENTS.md assertion to non-Windows.
- Add a cross-platform test exercising the REAL helper: one-arg call raises
  TypeError (the bug), two-arg returns bool — no '/' walk involved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)" (#172)

* fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)"

Follow-up to the built-in detection (#169). VS Code consolidates Copilot into the
`copilot` extension folder, whose manifest is name="copilot-chat",
displayName="GitHub Copilot Chat" ("AI chat features powered by Copilot") — it's
the Copilot Chat extension, and the one that consumes mcp.json. The built-in
detection was hardcoding "GitHub Copilot (VS Code)", mislabeling Chat as the
inline-completions product.

Derive the name from the bundle's package.json: name contains "copilot-chat"
(or displayName contains "chat") -> "GitHub Copilot Chat (VS Code)" (matching the
marketplace github.copilot-chat mapping); a plain "copilot" stays
"GitHub Copilot (VS Code)". Marketplace paths unchanged. Per-OS tests updated +
a plain-copilot generic case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: tighten chat heuristic + add Linux/Windows plain-copilot tests

Addresses Greptile (4/5) on #172:
- Narrow the displayName check from "chat" to "copilot chat" in all 3 detectors,
  so a hypothetical future variant like "GitHub Copilot (chat enabled)" isn't
  mislabeled as Chat. Still matches the real displayName "GitHub Copilot Chat".
- Add the plain-copilot generic-label test to the Linux and Windows classes
  (was macOS-only), so all 3 platforms cover both the chat and plain branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface (#173)

* feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface

An IDE-only GitHub Copilot user's shared ~/.copilot/skills were read by
nobody: the CLI skills extractor owns ~/.copilot but the CLI isn't detected
(skills/ is a SHARED marker excluded from CLI detection by #164), and the IDE
Copilot branch read nothing from ~/.copilot + has no skills extractor. This
enriches the detected VS Code Copilot row with the shared skills it actually
consumes (VS Code Agent Skills docs) — EXTRACTION-ONLY; detection and the #164
markers are untouched, so it cannot re-introduce the CLI false positive.

- S6: memoized _get_copilot_cli_skills() — one filesystem walk per scan,
  shared by the CLI + VS Code branches; CLI output byte-identical.
- S2: _set_canonical_vscode_copilot() picks ONE VS Code row (prefer Chat),
  computed from the full detected list; only that row carries skills.
- S5: user-scope skills keyed by each skill's own owner-home (from file_path)
  so multi-user/MDM scans don't leak skills across users.
- JetBrains excluded; Linux a graceful no-op (no CLI skills extractor).
- copilot-instructions.md + mcp-config.json stay CLI-only; ~/.copilot
  instructions deferred to a follow-up.

Scope: skills only. detect_copilot.py / copilot_cli.py / backend untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): make skill owner-home derivation OS-independent

_copilot_skill_owner_home ran the skill file_path through pathlib and
returned str(parent.parent). On a Windows interpreter, pathlib re-emits a
POSIX-style path with backslashes (str(WindowsPath('/Users/a')) ->
'\Users\a'), so the projects_dict key no longer matched the raw
forward-slash keys used everywhere else — failing the per-user keying
assertions on Windows CI (test_github_copilot_vscode_skills, 3 cases).

Derive the owner home by string-slicing at the .copilot/.agents marker
instead, preserving the input's separator style on any interpreter.
macOS and native-Windows output are byte-identical to before; only the
cross-OS (POSIX-path-on-Windows) case is corrected. Detection, the CLI
branch, copilot_cli.py and detect_copilot.py are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): address PR review — surface swallowed skills failure + dedup user skills

Two greptile review findings on the VS Code Copilot skills enrichment:

- P2: _get_copilot_cli_skills swallowed extraction failures at DEBUG, but the
  accessor is memoized so the CLI branch's existing WARNING never fired for a
  walk failure — invisible at the prod INFO floor. Upgraded to WARNING, matching
  the sibling skills-extraction error log.
- P1: user-scope skills were appended without dedup while project-scope skills
  10 lines above call _deduplicate_project_items. Dedup the owner-home bucket
  to match (by file_path). IDE branch only; CLI branch output unchanged.

Adds test_duplicate_user_skills_deduped_on_owner_home. The third finding
(per-sub-flow observability metric) was declined on the PR — inconsistent with
the sibling rules/MCP/permissions sub-steps, out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot-cli): demote hooks/ to SHARED — Unbound's own MDM hook caused fleet-wide phantom CLI (#174)

* fix(copilot-cli): demote hooks/ to a SHARED marker (Unbound's own MDM hook creates it)

#164 split ~/.copilot markers into STRONG (CLI-exclusive) vs SHARED, but kept
`hooks/` as STRONG. It is NOT CLI-exclusive: Unbound's OWN MDM onboarding
(websentry-ai/setup copilot/hooks/mdm/setup.py) runs for EVERY onboarded device
and does `(~/.copilot/hooks).mkdir(parents=True)` then writes unbound.json +
unbound.py — creating ~/.copilot/hooks/ from scratch on machines that never had
the CLI. So `hooks/` alone triggered a phantom "GitHub Copilot CLI" install
fleet-wide.

Confirmed in prod: device D2FJV74J5Q / user gowshik — ~/.copilot held only
hooks/unbound.json, `copilot --version` = not installed, no config/permissions.

Fix mirrors #164's ide/ demotion: move `hooks` STRONG->SHARED so it can never
alone declare a CLI install (it still enriches a real install). False-negative-
safe: a genuine CLI always also has a strong marker (config.json /
session-store.db / logs/). Detection of real installs is unchanged.

Adds regression tests: hooks/-only ~/.copilot is suppressed (gowshik repro), and
a real install with hooks/ + a strong marker is still detected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* address greptile review: complete the SHARED-marker docstring + Windows hooks guard

Two non-blocking gaps flagged on the PR:

1. `_copilot_dir_has_shared_artifact` docstring enumerated a stale shared-marker
   list that omitted both `ide/` (since #164) and the newly demoted `hooks/`, and
   wrongly attributed all shared markers to the IDE. Rewrote it to list all six
   (skills/agents/instructions/copilot-instructions.md/ide/hooks) with their true
   origins: IDE-read, IDE-written (ide/), and Unbound-MDM-written (hooks/).

2. The Windows detection test class had no hooks-specific assertion. Added
   `test_hooks_dir_alone_not_detected` to the existing TestWindowsCopilotCliDetection
   so the SHARED demotion is guarded on Windows too — catches a future regression
   if the MacOSCopilotCliDetector inheritance is ever broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot-cli): stop per-user over-attribution + extractor over-collection (#175)

Two independent extraction/attribution bugs found alongside the Copilot CLI
phantom-detection work (#164/#174). Detection and marker sets are untouched.

FIX #2 — per-user over-attribution. The CLI's install_path is a per-user
~/.copilot owned by exactly one user, but main()'s per-user loop re-emits every
detected tool for every OS user. filter_tool_projects_by_user scopes a tool's
projects/permissions to the user but never rewrites install_path, so a second
user (e.g. gowshik_2) who never had ~/.copilot still got a phantom "GitHub
Copilot CLI" row pointing at gowshik's home with 0 projects. Add an ownership
gate at the per-user emit site (CLI only): emit iff the user owns the detected
install OR the filter produced data for them. Extract the path-normaliser to a
module-level _normalise_path shared with filter_tool_projects_by_user (DRY).
Scoped to the CLI — IDE tools legitimately share a machine-wide install_path.

FIX #3 — extractor over-collection. The rules/skills project walks descended
into OTHER tools' per-user config dirs and their installed-extension packages
(e.g. ~/.antigravity/extensions/<pkg>/.github/instructions/*), mis-attributing
those bundled files to Copilot CLI. Add traverses_other_tool_config_dir() and
skip those dirs in both the rules walk (macOS + Windows _should_skip) and the
skills walk — while still allowing the shared .claude/.agents skill dirs a real
repo root legitimately carries (Agent Skills convention). Also dedupe repo-root
rule files by realpath + content so an AGENTS.md symlinked to / copied as
CLAUDE.md emits once. NOTE: CLAUDE.md/GEMINI.md remain collected — the official
GitHub Copilot CLI custom-instructions reference confirms the CLI reads them at
the repo root; only the symlink/copy double-count is removed.

Tests: tests/test_copilot_cli_per_user_attribution.py (12) and
tests/test_copilot_cli_overcollection.py (10). Full suite 902 green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs (H2/H4/H5) (#176)

* fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs

Three doc-verified GitHub Copilot discovery gaps (H2, H4, H5). H3 from the same
audit was REFUTED against official docs and is intentionally excluded — see PR
body. Detection/markers and MCP extraction are untouched.

H2 (CLI) — version="unknown" on root/MDM scans. get_version() probed a bare
`copilot` on the scanner's PATH; root's PATH lacks the per-user install, so the
version always read "unknown" on MDM all-users scans (an existing TODO flagged
this). Resolve the per-user binary from self.user_home first — ~/.local/bin,
~/.bun/bin, ~/.nvm/versions/node/*/bin (macOS, X_OK-checked like
find_claude_binary_for_user); AppData/Roaming/npm/copilot.cmd, .local/bin and
.bun/bin .exe (Windows, shell=True for the .cmd shim) — then fall back to the
bare-PATH probe (zero regression for the running-user case). Best-effort; still
degrades to "unknown".

H4 (VS Code) — wrong instructions dir. Read the documented
.github/instructions/**/*.instructions.md (recursive, depth-gated) instead of
the undocumented, over-broad .github/copilot/*.md. Removing the legacy path is a
deliberate collection-scope reduction (consistent with #175's anti-over-collection
direction); pinned by a negative test.

H5 (VS Code) — prompt files never collected. Collect *.prompt.md from project
.github/prompts/ and the user Code/User/prompts/ dir (already opened but globbed
only *.instructions.md). Prompt files are emitted as ordinary project/user-scoped
rule dicts with NO extra fields, because the backend silently discards any rule
carrying a non-allowlisted key — locked down by test_prompt_rule_has_only_allowed_fields.

find_github_copilot_project_root generalized to walk to the nearest .github
ancestor (serves nested instructions + prompts) without regressing the
prompts/intellij/AGENTS.md branches. Windows mirrors macOS.

Tests: new tests/test_github_copilot_instructions_prompts.py + extended
test_copilot_cli_discovery.py and test_scanning_enhancements.py. Full suite 916
pass; the 1 failing test (test_main_cli_with_queue_drain) is a pre-existing
environmental flake that also fails on clean main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(copilot): gate POSIX-only version stub tests to fix Windows CI

test_version_resolved_from_local_bin_stub / _from_nvm_stub create a #!/bin/sh
executable stub and assert the macOS detector probes it. Windows can't exec a
shebang script, so get_version() returned None there (Windows CI red). The
exec path is inherently POSIX; the Windows per-user-binary path is already
covered portably by test_version_probed_from_per_user_npm_shim (which mocks
subprocess.run). Gate the two stub-exec tests with skipUnless(os.name=="posix").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): resolve newest nvm Node version for CLI binary (greptile P2)

_resolve_copilot_binary iterated nvm version dirs in arbitrary glob order, so a
user with multiple nvm-managed Node versions (each with a copilot install) could
resolve a stale one. Sort by NUMERIC (major,minor,patch) parsed from the dir
name, newest first — note a plain string sort (greptile's suggestion) orders
"v9" after "v10"; the numeric key fixes that. Also makes a re-scan deterministic.
Adds a POSIX-gated test (macOS resolver) asserting v18 wins over v10/v9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add Sync Staging with Main workflow (#179)

Keeps staging in sync with main after each release to main.
Triggers on push to main (and workflow_dispatch).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot-vscode): read .claude/rules + user ~/.copilot|.claude instructions; scope MCP per IDE surface (#178)

* fix(copilot-vscode): read .claude/rules + ~/.copilot|.claude user instructions; scope MCP per IDE surface

Two related GitHub Copilot VS Code Chat discovery fixes (macOS + Windows;
Linux tracked separately). Verified against the official VS Code Copilot
custom-instructions docs.

(1) Rules completeness — the VS Code rules extractor now reads the three
documented "Default file location" custom-instruction sources it was missing:
  - workspace .claude/rules/**/*.md  (Claude format)
  - user ~/.copilot/instructions/**/*.instructions.md
  - user ~/.claude/rules/**/*.md
find_github_copilot_project_root now resolves the nearest .github/.claude/.copilot
ancestor (keys these to the right repo root / user home) without regressing the
prompts/intellij/AGENTS.md branches. New _extract_claude_rules helper +
add_user_rules refactor. Guards: skip the user-home ~/.claude (collected as user
scope, no double-count since add_rule_to_project doesn't dedupe) and skip
other-tool config dirs / installed extension packages (traverses_other_tool_config_dir).

(2) MCP identity scoping — extract_mcp_config was identity-blind: it unioned
VS Code global + JetBrains global + workspace .vscode/mcp.json and returned that
to EVERY Copilot row, so on a machine with both IDE Copilots each row showed the
other's servers. Now gated by tool_name: a VS Code row gets VS Code global +
workspace; a JetBrains row gets JetBrains global only; tool_name=None keeps the
legacy union (back-compat). Pure narrowing — single-IDE users unaffected. Call
site passes the detected row name. Mirrors the already identity-aware rules
extractor.

Tests: all regression coverage lives in the one focused file
tests/test_github_copilot_instructions_prompts.py (.claude/rules project +
allowlist guard + extension-dir-skipped + user-home-not-double-collected; user
~/.copilot/instructions + ~/.claude/rules; MCP identity scoping). Full suite
green; the lone failure (test_main_cli_with_queue_drain) is a pre-existing
environmental flake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot-vscode): Linux MCP tool_name param (regression) + user-rules depth guard

Follow-up to the review of this PR.

- CRITICAL: the call site passes extract_mcp_config(tool_name=...) for all OSes,
  but the Linux GitHub Copilot MCP extractor's signature was still
  extract_mcp_config(self) -> TypeError (swallowed) -> Linux Copilot MCP servers
  returned empty (a regression vs main). Linux now accepts tool_name and applies
  the same VS Code / JetBrains surface gating as macOS/Windows.
- add_user_rules now depth-gates its globs (~/.copilot/instructions/**,
  ~/.claude/rules/**) with MAX_SEARCH_DEPTH, matching _extract_claude_rules
  (macOS + Windows).
- Windows user-rules debug log relabeled "Found VS Code Copilot user rule" (it
  now also covers ~/.copilot/instructions and ~/.claude/rules).

Note: the MCP JetBrains gate stays the simple `not is_vscode` (correct for every
github_copilot surface the detector emits today); a speculative positive
predicate was considered and dropped as over-engineering per principal review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (#183) (#184)

* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (fixes #1, #3)

Fix #1: the GitHub Copilot (VS Code) MCP extractor's _read_mcp_config called
json.loads() directly despite a docstring claiming it stripped comments, so any
hand-edited mcp.json with // or /* */ comments or a trailing comma threw
JSONDecodeError and silently yielded 0 servers. Now strips comments + trailing
commas before parsing, reusing the existing string-aware helpers.

Fix #3: removed the dead globalStorage/ms-vscode.vscode-github-copilot/mcp.json
fallback. That publisher/extension id does not exist (real ids are
GitHub.copilot / GitHub.copilot-chat) and VS Code never stores MCP config in
extension globalStorage, so the branch could never match a real install.

DRY: relocated _strip_jsonc_comments / _strip_trailing_commas into the shared
mcp_extraction_helpers.py as the single source of truth; macos/copilot_cli
re-exports them for back-compat. Applied across all three OS variants.

Fix #2 (profiles / Insiders / legacy settings.json MCP locations) is
intentionally deferred to a follow-up PR.



* Trim verbose comments on JSONC strippers

Condense the explanatory block comments in mcp_extraction_helpers.py to
concise one-liners; no code change.



* Drop ticket/task-specific references from test comments

Remove WEB-4703 / "fix #1" / "fix #3" labels from the JSONC test module
docstrings and section comments; keep the behavioral descriptions. No test
change.



---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (#185) (#188)

* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (fix #2, scoped A+B)

The GitHub Copilot (VS Code) MCP extractor read only the default-profile
Code/User/mcp.json. Since VS Code 1.102 MCP is per-profile, so any user on a
named profile (Code/User/profiles/<id>/mcp.json) had their servers missed; VS
Code Insiders users were also skipped even though detection already counts them
(detect_copilot._VSCODE_USER_DATA_DIRS).

Adds a shared, bounded, crash-safe enumerator enumerate_vscode_mcp_files() that
yields the default mcp.json plus sorted profiles/*/mcp.json for one Code/User
base. Each OS extractor now iterates [Code/User, Code - Insiders/User] through
it and attributes each config to its own dir (str(mcp_file.parent)), so distinct
profiles/variants surface as distinct sources.

Customer-agnostic and additive: a machine with only the default-profile mcp.json
produces byte-identical output. Reuses the JSONC strippers from #183.

Scoped to A+B. Fix C (legacy settings.json mcp key) is intentionally deferred:
1.102 auto-migrates settings.json -> mcp.json, so the remaining population is
small and shrinking, and it would add dedup/precedence complexity that profiles
+ Insiders (disjoint locations) do not need.

Predecessor: #183 (fixes #1 + #3).



* Log at debug when enumerate_vscode_mcp_files skips on FS error

Addresses Greptile P2: the two except blocks swallowed PermissionError/
OSError silently; log at debug to match the rest of the extraction layer
(still never raises). No behavior change to returned files.



---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable (#186) (#189)

* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable

On a shared-HOME macOS host where discovery runs under two uids -- classically a
root MDM agent and the login user on the same machine (the failing host is an EC2
Mac) -- discovery-cache.json is written 0600 by whichever uid runs first. The other
non-root uid then gets PermissionError [Errno 13] reading it. Surfaced as Sentry
DISCOVERY-TOOL-SCRIPT-11.

_ensure_state_dir() already falls back to a per-uid /var/tmp/unbound-{uid} dir, but
_try_state_dir() only probed the *directory's* writability, never whether an existing
discovery-cache.json was *readable*. A home whose dir is writable but whose cache file
is a foreign-owned 0600 was accepted as healthy, then read_cache() raised EACCES on it.

Probe the cache file's readability too: a candidate holding a cache file this uid
cannot read is rejected, so the resolver falls through to the per-uid temp dir -- the
same uid-namespacing utils._get_queue_file_path() already uses for the queue file.
os.access() uses the real uid, so root (which can read any file) keeps using its own
home cache unchanged, leaving root/all-users scans intact.

Scope: this fixes the cross-uid collision and the file-readability probe gap.
Downgrading the *expected* permission warning that read_cache/atomic_write_cache emit
to Sentry is tracked separately and intentionally not included here.

Fixes DISCOVERY-TOOL-SCRIPT-11



* WEB-4702: surface fallback reason in state-dir warning; condense probe comment

Address PR review: _ensure_state_dir's fallback warning now includes
last_lock_error, so a fall-back to the per-uid temp dir logs *why* the home dir
was rejected (unreadable cache vs. unwritable dir vs. OSError) before it is
cleared. Also condense the readability-probe comment to a single line.



* WEB-4702: trim verbose comments in the cache-fallback test

Condense the readability-fallback test's comments to match the source cleanup.
No behavior change.



---------


(cherry picked from commit 87114e8)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(discovery): Tier-B detector accuracy — binary-gate Cursor/Copilot CLI, extensions.json-gate Cline/Roo/Kilo (#198) (#201)

* fix(discovery): gate Cursor/Copilot CLI on binary + Cline/Roo/Kilo on extensions.json (Tier B)

Tier-B detector-accuracy follow-up to #193 — fixes the 5 remaining detectors that
gated 'installed' on config/globalStorage residue that survives uninstall (or is
created by another tool). All 5 doc-verified vs official vendor docs.

Extensions (Cline, Roo, Kilo Code): gate on the ext-id being a live entry in
<editor>/extensions/extensions.json (the install registry VS Code rewrites on
uninstall) via a new shared vscode_extension_helpers.find_extension_in_editor
(case-insensitive id — fixes Kilo's lowercase-on-disk kilocode.kilo-code, silently
broken on Linux ext4). Dropped the globalStorage check (VS Code won't clean it —
microsoft/vscode#119022) and the host-IDE co-check. Roo gains a VSCodium host.

CLIs (Cursor CLI, Copilot CLI): gate on the binary. Cursor CLI -> cursor-agent (and
fixed the .detect() fallback probing 'cursor' = the IDE launcher, which mis-labeled
the IDE as the CLI). Copilot CLI -> the copilot binary (npm + Homebrew, owner-
attributed under root); + NEW Linux detector (Copilot CLI was undetected on Linux).
install_path is now the binary, so a config_path field carries ~/.copilot and the
orchestrator's permissions/skills/ownership key on it (else permissions silently drop).

Detection-only, no backend/FE change. Tests: integration-level both-directions per
tool/OS incl. globalStorage-residue-without-entry FP-kill, the Cursor IDE-mislabel
guard, Copilot hooks-only/machine-global cases, and config_path full-chain attribution.



* test(copilot-cli): skip the X_OK non-exec test on Windows

test_non_executable_binary_not_detected asserts a non-executable ~/.local/bin/copilot
is not detected, but os.access(X_OK) returns True for ANY file on Windows -> the binary
reads executable there and the test fails (Windows CI). The X_OK gate is POSIX-only;
gate the test with @unittest.skipIf(os.name=='nt'), matching the existing Claude
test_non_executable_binary_not_detected skip. macOS (3.9/3.11/3.12) already green.



* fix(copilot-cli): get_version uses self._resolve_binary, not the module resolver (Greptile)

get_version() resolved the binary via the module-level _resolve_copilot_binary (per-user
only: ~/.local/bin, ~/.bun/bin, nvm), but detection uses self._resolve_binary, which
Linux/Windows override to add the npm-global prefix, /usr/local/bin, and AppData npm. So a
copilot detected via those OS-specific locations reported version 'unknown' (get_version's
fallback shells 'copilot --version' on the scanner PATH, empty under root MDM). Route
get_version through self._resolve_binary so version resolution matches detection on every OS.



* fix(discovery): close 3 binary-coverage gaps — Cursor Win-native, Copilot WinGet + Linuxbrew

Coverage verification vs official install docs/scripts found 3 documented install methods
the binary resolvers didn't probe -> false negatives:

1. Cursor CLI Windows: the native installer (irm cursor.com/install?win32) writes
   %LOCALAPPDATA%\cursor-agent\{cursor-agent,agent}.{exe,cmd} + versions\<v>\, but we probed
   only %USERPROFILE%\.local\bin\cursor-agent.exe -> every native-Win user missed. Added the
   LOCALAPPDATA paths (existence-gated) + versioned subdir (numeric-newest) + the Git-Bash
   extensionless ~/.local/bin/cursor-agent. Hoisted _version_key to module level.

2. Copilot CLI Windows: 'winget install GitHub.Copilot' drops a Links shim; verified vs the
   winget manifest (Commands: [copilot]) the shim is copilot.exe -> added
   %LOCALAPPDATA%\Microsoft\WinGet\Links\copilot.exe (mirrors the Claude WinGet path).

3. Copilot CLI Linux: 'brew install copilot-cli' is official on Linux (Linuxbrew) -> added
   ~/.linuxbrew/bin/copilot (user-relative) + /home/linuxbrew/.linuxbrew/bin/copilot
   (machine-global, owner-attributed under root via machine_global_binary_owned_by_user -- no
   cross-user FP). /usr/local/bin now also owner-gated under root.

Tests: both directions, hermetic; the 6 positive tests fail against pre-fix code.



* refactor(copilot-cli): rename internal config_path field -> _config_path (review WARNING)

The Copilot CLI detector emits the resolved ~/.copilot dir so the orchestrator can
attribute per-user settings/skills/ownership (install_path is the binary now). The field
was named 'config_path' (no underscore), so generate_single_tool_report did NOT strip it
-> it leaked to the backend payload (exposing the user's home path). Rename to '_config_path'
to match the internal-field convention (JetBrains uses _config_path) so it's stripped.
Consumed BEFORE the strip (ownership/skills/permissions attribution unchanged), absent from
the sent payload.

Sites: the emit (macOS copilot_cli, inherited by win/linux) + the 5 orchestrator reads/carry/
log + docstrings. Tests updated; new test_config_path_stripped_from_backend_report asserts the
field drives attribution but never reaches the report.



* fix(cli): resolve Cursor/Copilot CLI version from the detected binary, not the scanner PATH

Both detectors found the binary correctly (install_path) but fetched the VERSION indirectly
— Cursor via a bare 'cursor-agent --version' against the scanner's PATH, Copilot by re-resolving
via self.user_home — so under a root/MDM all-users scan the version read 'Unknown' even though
the binary was found (root's PATH lacks the user's copy). Thread the already-resolved binary into
get_version(self, binary=None) (backward-compatible: no-arg keeps the old behaviour);
_detect_cursor_cli passes cursor_agent_bin, Copilot _detect_for_user passes the binary it
resolved. Probe '<binary> --version' directly — no re-resolve, no bare-PATH fallback.

Preserves doc-verified caveats: the --version FLAG (offline, not the network 'version'
subcommand); the multi-line-banner parsers; VERSION_TIMEOUT + swallow-to-unknown; the Windows
.cmd shim shell=True path (Cursor Windows quotes the spaced path via list2cmdline; Copilot Windows
routes through _probe_version).

Tests: root-scan version resolution for both (binary off the scanner PATH / self.user_home unset
-> version still parsed from the resolved binary), proven non-vacuous; Windows quoted-path +
back-compat; existing version tests green.



* docs: trim verbose/task-specific comments to brief reason-only (PR #198)

Comments should convey the WHY, briefly — not the whole story. Removed task/PR/commit/review
references (93b5fc2, W1, doc-verified, device serials, follow-up asides), multi-sentence
narratives, and restatements of obvious code from THIS PR's added comments; kept the brief
non-obvious reasons (globalStorage survives uninstall, shell=True for the npm .cmd shim,
which/npm-prefix root-PATH guards, case-insensitive ext-id, Windows X_OK). Comments/docstrings
only — no code/logic changed (AST-verified); suite green (1200).



* fix(linux/copilot-cli): don't double-count other users' user-level skills on root scans

The Linux Copilot CLI skills extractor called is_user_level_claude_subdir(type_dir)
single-arg, which derives the users-root from Path.home(). On an MDM root scan
Path.home()==/root (parent /), so a NON-scanner user's /home/<user>/.agents/skills
(and .claude/skills) was not recognized as user-level and got re-emitted by the
project walk as a project skill -- even though _extract_user_level_skills already
emitted it user-scope. Result: duplicate / mis-scoped skill rows on the primary
(MDM root) Linux deployment.

Add a Linux-aware _is_user_level_skill_dir override (pin users-root to /home for
the /home/<user> shape + explicit /root check), mirroring macOS behavior that
worked only because all homes share /Users. Ported from PR #157's guard and its
TestLinuxRootSkillsUserLevelGuard regression test (this project has no Linux CI
runner, so the test is pure path-logic and runs on every runner).

Also resolves the open Greptile P2s on this PR:
- remove dead _check_ide_installation + now-unused imports (linux/cline,
  linux/roo_code) and the dead method + orphan IDE_APP_NAMES/APPLICATIONS_DIR
  (macos/kilocode), left over from dropping the host-editor AND-gate
- fix stale class docstrings still describing globalStorage gating
  (macos/cline, macos/roo_code) and install_path=~/.copilot (windows/copilot_cli)

1269 tests pass (+3 new). Detection-only, no backend/FE change.



---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: audit <audit@local>

* test(WEB-4673): cover extract_dual_path_configs_with_root_support dedup

The dual_path root-support helper got the _own_home_already_scanned guard but
was the only one of the four without a no-double-count test. Add it (own
preferred path read once on the Windows-admin case) plus the macOS-root contrast
(root's preferred path, outside /Users, is still read) — full parity with the
global helper's coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: NandaPranesh <106886030+anonpran@users.noreply.github.com>
Co-authored-by: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com>
Co-authored-by: Pugazhendhi <pugazhendhi@unboundsecurity.ai>
Co-authored-by: MohamedAklamaash <aklamaash78@gmail.com>
Co-authored-by: vishnu <vishnuvinod072@gmail.com>
Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com>
Co-authored-by: Mohamed Aklamaash M.R <111295679+MohamedAklamaash@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Nanda Pranesh <nandapranesh27@gmail.com>
Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com>
Co-authored-by: Sumit Badsara <sumit@unboundsecurity.ai>
Co-authored-by: Sumit Badsara <sumitbadsara.dev@gmail.com>
Co-authored-by: audit <audit@local>
…ws), claude_cowork (#202)

* fix(discovery): Tier-C residue cleanup — junie binary/plugin gate, copilot-Windows extensions.json, cowork install-gate

Three detection-only residue-correctness fixes (follow-up to #198). Each replaces a
gate that survives uninstall with a signal that disappears on uninstall.

- junie: replace the ~/.junie dir gate (a user-authored guidelines dir that survives
  uninstall AND misses real installs) with a binary-OR-JetBrains-plugin gate. New
  find_junie_binary_for_user (mirrors find_claude_binary_for_user, root-owner-attributed)
  + _detect_junie dispatch; the JetBrains-plugin check is scoped per-user via
  _scan_jetbrains_config_dir(user_home) (NOT the all-users detect()) + a
  _path_under_user_home guard, so a root/MDM scan can't fan out one user's plugin to
  other users. ~/.junie kept only as the version source.
- github_copilot (Windows VS Code): read the live extensions.json entry via
  find_extension_in_editor instead of a github.copilot* folder glob (the folder survives
  uninstall, microsoft/vscode#81046) — now matches the already-safe macOS/Linux path.
- claude_cowork (Windows + Linux): AND an install-presence check (_find_install_dir) with
  the local-agent-mode-sessions/ dir, in BOTH detect() and the central
  _detect_claude_cowork (the production root/MDM path); macOS unchanged. Fix the stale
  factory comment to include Linux.

jetbrains residue (config-dir gate) is deferred to a separate ticket (WEB).

1314 tests pass. Detection-only — no backend/FE change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(discovery): skip POSIX-only junie/cowork cases on Windows

CI runs unittest discover (all tests, no platform skip). Four new tests are
inherently POSIX: the two junie machine-global owner-attribution cases patch
pwd.getpwuid (absent on Windows), and the macOS /Applications/Claude.app +
Linux /opt/Claude install-path asserts use forward-slash string semantics
(backslash on Windows). Guard them with skipIf(os.name == 'nt'), mirroring the
existing gemini/claude sibling tests. macOS coverage unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(discovery): reuse is_root sentinel in find_junie_binary_for_user (Greptile P2)

Single is_running_as_root() call threaded through the npm-global and PATH
backstops instead of re-calling it, matching find_claude_binary_for_user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(discovery): address Greptile review on cowork — consistent install_path + per-user install dir

Two correctness gaps Greptile flagged on the claude_cowork fix:

1. install_path divergence: this PR had changed the Win/Linux OS detect() to
   report the install dir while the central _detect_claude_cowork (and the
   untouched macOS detector) report the sessions dir. Revert the OS modules to
   report sessions_dir so all paths/OSes stay consistent (the pre-PR contract);
   the resolved install dir remains the GATE, not the reported path.

2. Windows admin/MDM false-negative: WindowsClaudeCoworkDetector._find_install_dir
   resolved candidates from Path.home() (the scanner's home), so a multi-user
   admin scan for user B probed the scanner's LocalAppData, missing B's per-user
   Claude Desktop install. Thread the scanned user_home through
   _candidate_install_dirs/_find_install_dir, and pass it from the central path.
   Linux candidates are machine-global (unaffected) but accept the param for a
   uniform signature.

+2 regression tests (admin scan resolves B's home not the scanner's; no
cross-user attribution). 1316 tests pass under pytest + unittest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: audit <audit@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--summary was still registered in the production argparse but nothing
read args.summary once concise output became the default, so it was a
dead, misleading flag — callers passing it silently got default output
instead of an error. Remove it so the verbosity group is --dump/--payload
only and stale callers fail loudly (matching the test mirror, which had
already dropped it).

Also fix the now-stale --dump help text (it still claimed to log the JSON
payload, which moved to --payload) and de-fragment the comment on the
concise-default branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP tool scanner spawns stdio servers verbatim to list their tools.
For servers configured via mcp-remote (e.g. npx -y mcp-remote <url>),
spawning it without a cached OAuth token makes mcp-remote start an
interactive OAuth flow and open a browser tab. On a scheduled discovery
scan that surfaces as repeated, unwanted auth tabs.

Before scanning, detect mcp-remote stdio configs and check whether
mcp-remote already has a cached token for that server (replicating its
md5(serverUrl[|resource][|headers]) key, honoring MCP_REMOTE_CONFIG_DIR).
If no token exists, skip spawning entirely and report auth_required; the
browser flow can only occur if the process runs, and now it never does.
When a token is cached, scanning proceeds unchanged and mcp-remote uses
it non-interactively (including silent refresh).
…emote

fix(discovery): skip mcp-remote scan when it has no cached token
Bump DEFAULT_RUN_TIMEOUT_SECONDS 5400 -> 9000 to allow large fleets /
slow scans more headroom before the discovery process self-terminates.
Kept in sync with setup/mdm/onboard.py and unbound-cli's discover.js.

Co-authored-by: audit <audit@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gent) (#207)

* WEB-4855: send real-or-None system_user with scan lifecycle events

Split get_user_info into a guaranteed scan-target resolver and a new get_audit_user that returns the real OS user or None (rejecting root, macOS daemons, SYSTEM, Windows machine accounts, Linux service accounts, and "unknown"). Carry it on scan lifecycle events so the backend can attribute empty machines without recording junk identities. Also fixes the pre-existing Windows DOMAIN\\user parsing bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4855: reject Windows built-in/service identities in _real_user_or_none

Address consensus security review on #207: (1) NT AUTHORITY\\LOCAL SERVICE, NETWORK SERVICE, Administrator and other Windows built-ins survived the filter; add them to the denylist and reject the NT AUTHORITY / NT SERVICE service-principal domains generically. (2) Make _real_user_or_none self-contained by stripping the DOMAIN\\ prefix itself, so the filter is safe regardless of call path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4855: carry system_user on run-level failed scan events + log None rejection

Addresses Cursor 'global failed scan omits system_user' (Medium): initialize the audit system_user to None before the failure closures and pass it on the run-level failed event, so a failure after capture stays attribution-consistent with in_progress/completed (low impact in practice — in_progress already attributes and fill-if-empty protects, but removes the inconsistency). Adds a logger.debug when the audit identity resolves to None (Greptile observability nit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4855: doc polish — correct get_user_info Windows docstring + note denylist trade-off

- get_user_info docstring claimed Windows explorer.exe/Win32/console-session
  detection the code no longer does; describe the actual whoami+strip behaviour.
- Note the _NON_HUMAN_USERS trade-off: a rare human whose login equals a service
  name is also mapped to None (acceptable — a false None beats a wrong owner).

Comment-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4855: close NT SERVICE\<name> audit bypass on Windows — Greptile

get_audit_user() filtered get_user_info(), but get_user_info() pre-strips the
DOMAIN\ prefix, so a Windows service principal like NT SERVICE\MSSQLSERVER
reached _real_user_or_none as the bare, non-denylisted name "MSSQLSERVER" and
leaked through as a human owner. The NT AUTHORITY / NT SERVICE domain rejection
never fired.

Fix: on Windows, feed the RAW domain-qualified whoami output to
_real_user_or_none (it strips the domain itself, after the domain check). Falls
back to get_user_info() when whoami is empty.

Tests: NT SERVICE\MSSQLSERVER / WinDefend / NT AUTHORITY\* -> None;
CORP\alice -> "alice"; empty whoami -> get_user_info fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WEB-4855: fix TestGetAuditUser Windows CI failure (pin platform to non-Windows)

get_audit_user() takes a raw-whoami branch on Windows (f1819c4), which bypasses the get_user_info patch these 3 tests rely on, so the Windows runner's real account (runneradmin) leaked through and failed them. Pin platform.system to 'Darwin' so they deterministically exercise the get_user_info path they patch; the Windows raw-whoami branch is already covered by TestGetAuditUserWindowsService. Test-only; no production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: audit <audit@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hook attaches base64 script body to server_config before dispatch; scan_one
forwards it to the reported object so the backend can store it and derive
the script:<hash> fingerprint.
* JetBrains detector: name IdeaIE/Aqua, skip non-IDE client folders

Add IdeaIE (IntelliJ IDEA Educational) and Aqua to IDE_NAME_MAPPING so
they report clean names with versions stripped instead of raw folder
names like IdeaIE2022.2 / Aqua2024.1.

Align macOS and Linux SKIP_FOLDERS with Windows to exclude the
JetBrainsClient remote-dev thin client and consent/DeviceId folders,
which are not IDEs and were surfacing as phantom tool rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Treat IntelliJ IDEA Educational (IdeaIE) as a free edition

_detect_plan only recognized IdeaIC/PyCharmCE as free, so IdeaIE
installs were tagged Licensed. IDEA Educational is free; add it to the
free-edition check across all three OS detectors. Aqua is a paid
product, so Licensed remains correct for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: audit <audit@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the skip-if-no-tools guard. URL-less servers (e.g. Claude desktop OAuth
remote connectors, which can't be tool-scanned) must still report so the control
plane can create metadata and fingerprint/group them. The backend already
upserts metadata regardless of tool count.

Behavior change: a single-server scan that yields 0 tools now always POSTs.
scan: forward script_content from server config to scan object
…on (#206)

Per-user onboarding now scans with the user's own API key, so the scheduled
`onboard` run no longer needs a separate discovery key. Stop requiring it at
install time and in the cron wrapper (bash + PowerShell); forward it only when
one was stored, for older/MDM-style setups. MDM/all-users scheduled scans use
the `discover` command and are unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@pugazhendhi-m pugazhendhi-m requested a review from a team June 25, 2026 14:47
@vigneshsubbiah16

Copy link
Copy Markdown
Contributor

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 955b8b8d · 2026-06-25T14:51Z

# (it resolved the path with cwd; we run detached without it). The backend
# recomputes sha256 -> `script:<hash>` fingerprint and stores the body.
if obj is not None and isinstance(server_config, dict) and server_config.get('script_content'):
obj['script_content'] = server_config['script_content']

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

Description: The new flow forwards raw script_content from local MCP server config into the outbound mcp_server payload, and this path is explicitly documented as stored server-side. Because these launcher scripts can contain hardcoded secrets or sensitive internal logic, this introduces backend collection of high-sensitivity local script bodies without visible minimization or gating.

Impact: Sensitive local code/config material can be exfiltrated and durably retained in control-plane storage, increasing exposure risk if tokens, credentials, or proprietary logic are embedded in script files.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 955b8b8. Configure here.

Comment on lines +413 to +423
if str_args[i] == "--header":
match = _MCP_REMOTE_HEADER_RE.match(str_args[i + 1])
if match:
headers[match.group(1)] = match.group(2)
i += 2
continue
if str_args[i] == "--resource":
resource = str_args[i + 1]
i += 2
continue
i += 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 _mcp_remote_has_cached_token uses the scanner's home, not the user being scanned

Path.home() resolves to the running process's home directory — /root or /var/root under a root/MDM multi-user scan. When _scan_servers_in_mapping processes configs for a non-root user (e.g., Alice), this function checks /root/.mcp-auth for Alice's token instead of /home/alice/.mcp-auth. If Alice has a valid cached token, the function returns False, and _mcp_remote_unauthed_result reports auth_required — causing Alice's mcp-remote server to be skipped entirely. This affects every non-root user in every root/MDM enterprise deployment that uses mcp-remote.

The fix requires threading user_home from _scan_servers_in_mapping_mcp_remote_unauthed_result_mcp_remote_has_cached_token and replacing str(Path.home() / ".mcp-auth") with str(user_home / ".mcp-auth").

Comment on lines +529 to +530
is_root = is_running_as_root()
for candidate in machine_global + user_relative:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 machine_global candidates are iterated before user_relative, which is the reverse of the ordering used in find_cursor_agent_binary_for_user. Under a non-root scan on a shared Linux system, a /usr/local/bin/junie installed system-wide (possibly by another user or a package manager) would be returned before checking the scanning user's own ~/.local/bin/junie. Owner-attribution only guards this under root — the check is a no-op for non-root runs. Consider scanning user_relative before machine_global for consistency.

Suggested change
is_root = is_running_as_root()
for candidate in machine_global + user_relative:
is_root = is_running_as_root()
for candidate in user_relative + machine_global:

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 955b8b8. Configure here.

if find_install_dir(user_home) is None:
return None
except (PermissionError, OSError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linux Cowork cross-user false positive

Medium Severity

Multi-user and privileged scans are incorrectly attributing tool installations and cached authentication tokens. For Claude Cowork on Linux, detection uses machine-wide install checks that don't respect the scanned user's home, causing false positives. The mcp-remote auth short-circuit also checks for cached tokens in the scanner's home directory instead of the scanned user's, leading to auth_required errors or incorrect token usage.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 955b8b8. Configure here.

@pugazhendhi-m pugazhendhi-m merged commit 0675c9f into main Jun 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants